Skip to content

Conversation

@petkybenedek
Copy link
Contributor

What's new

This PR introduces AsyncStore, which makes use of structured concurrency to interact with the API and Core Data.

refs: MBL-19677
builds: Student, Teacher, Parent
affects: Student, Teacher, Parent
release note: none

Checklist

  • Follow-up e2e test ticket created
  • A11y checked
  • Tested on phone
  • Tested on tablet
  • Tested in dark mode
  • Tested in light mode
  • Approve from product

refs: MBL-19677
builds: Student, Teacher, Parent
affects: Student, Teacher, Parent
release note: none
Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR introduces a new AsyncStore implementation that provides async/await alternatives to the existing reactive Store pattern. The changes align well with Swift's modern concurrency model and follow the project's MVVM architecture.

Issues Found

  • Critical Bug in NSPersistentContainerExtensions.swift:99-103 - The performWriteTask methods are using self.context instead of the computed context property, which defeats the purpose of having a dedicated write context. This could lead to threading issues.

  • Memory Leak Risk in AsyncStore.swift:139 - The streamEntitiesFromDatabase() method returns a non-terminating stream without built-in cancellation handling. While the warning comment is present, consider adding safeguards or helper methods.

  • Silent Error Handling in AsyncStore.swift:147 - The forceRefresh method silently ignores errors with try?. This should at least log errors for debugging purposes.

  • SwiftLint Violation in AsyncStore.swift:54 - Line length exceeds the project's limit and should be broken into multiple lines.

Positive Aspects

  • Clean async/await API: The new AsyncStore provides a modern, ergonomic interface that will improve code readability compared to Combine publishers.

  • Offline mode support: Proper integration with OfflineModeInteractor ensures consistent behavior across online/offline states.

  • Smart caching: The cache validation logic is well-implemented with hasCacheExpired checks before making API calls.

  • Consistent error handling: Most methods properly propagate errors through async throws, giving callers control over error handling.

  • Pagination support: The loadAllPages parameter provides flexibility for different data loading scenarios.

  • Good separation of concerns: Private helper methods like fetchEntitiesFromCache, fetchEntitiesFromAPI, and fetchEntitiesFromDatabase keep the code organized and testable.

Recommendations

  1. Fix the critical bug: The performWriteTask implementation must use the write context, not the view context.

  2. Add unit tests: Given the complexity of async streams and Core Data operations, comprehensive unit tests are essential. Follow the conventions in CLAUDE-unit-tests.md.

  3. Consider adding cancellation helpers: For non-terminating streams, provide convenience methods that handle cancellation automatically (e.g., with timeouts or lifecycle-bound cancellation).

  4. Add logging: At minimum, log errors in forceRefresh to aid debugging.

  5. SwiftLint compliance: Ensure all code passes SwiftLint checks before merging.

public func getEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> [U.Model] {
let scope = useCase.scope
let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self))
request.predicate = scope.predicate
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftLint violation: This line exceeds the line length limit. Consider breaking it into multiple lines for better readability.


/// - Warning: This stream **does not terminate**. Ensure proper cancellation of its consuming task.
public func streamEntitiesFromDatabase() throws -> AsyncThrowingStream<[U.Model], Error> {
let scope = useCase.scope
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: This stream does not terminate and can cause memory leaks if not properly cancelled. Consider:

  1. Adding a timeout mechanism
  2. Providing a helper method that handles cancellation
  3. Adding unit tests that verify proper cleanup when the consuming task is cancelled

return Self.streamEntitiesFromDatabase(fetchRequest: request, context: context)
}

/// Refreshes the entities by requesting the latest data from the API.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from forceRefresh is being silently ignored with try?. This could hide important issues. Consider:

  1. Logging the error for debugging
  2. Using a completion handler or throwing the error to the caller
  3. At minimum, adding a comment explaining why it's safe to ignore errors here


@objc open func performWriteTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
let context = writeContext ?? {
context.perform { block(self.context) }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: This implementation is incorrect. The method is supposed to use a write context, but it's always using self.context instead of the actual context variable returned by the context computed property.

The block(self.context) should be block(context) where context is assigned from the context computed property.

}

public func performWriteTask<T>(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T {
return try await context.perform { try block(self.context) }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Same issue here - this should use the write context from the context computed property, not self.context. The correct implementation should be:

let writeContext = context
return try await writeContext.perform { try block(writeContext) }

@petkybenedek petkybenedek changed the title [MBL-19677] [S/P/T] Structured Concurrency foundations [MBL-19677][S/P/T] Structured Concurrency foundations Jan 21, 2026
@inst-danger
Copy link
Contributor

inst-danger commented Jan 21, 2026

Warnings
⚠️ This pull request will not generate a release note.

Affected Apps: Student, Teacher, Parent

Builds: Student, Teacher, Parent

MBL-19677

Coverage New % Master % Delta
Canvas iOS 91.38% 81.06% 10.31%

Generated by 🚫 dangerJS against 6d1c18a

@inst-danger
Copy link
Contributor

inst-danger commented Jan 21, 2026

Builds

Commit: Add equatable conformance (6d1c18a)
Build Number: 1227
Built At: Jan 29 16:49 CET (01/29 08:49 AM MST)

Student
Teacher
Parent

@petkybenedek petkybenedek self-assigned this Jan 22, 2026
let entities = try await getEntities(ignoreCache: ignoreCache, loadAllPages: loadAllPages)

if assertOnlyOneEntityFound, entities.count > 1 { throw AsyncStoreError.moreThanOneEntityFound(entities.count) }
guard let entity = entities.first else { throw AsyncStoreError.noEntityFound }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, here we are treating the empty data (no entities) as failure. Wouldn't that confuse code at call sites? As that would often be treated as a separate case in terms of UI ?

Copy link
Contributor

@suhaibabsi-inst suhaibabsi-inst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@petkybenedek Great Work! I liked how AsyncFetchedResults turned to be. Though I have a couple of comments to consider to fully utilize the syntactic beauty of async/await coding.

import Foundation
import CoreData

@preconcurrency
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking if we can get rid of this @preconcurrency attribute be used with our newly introduced Async types. How about using it with @preconcurrency import CoreData as that was by Apple, and then move the fetch method to an extension of NSManagedObjectContext.

@preconcurrency import CoreData

public final class AsyncFetchResults<ResultType: NSFetchRequestResult> {
....
    public func fetch() async throws -> [ResultType] {
        try await context.fetch(request)
    }
}

extension NSManagedObjectContext {
    public func fetch<R: NSFetchRequestResult>(_ request: NSFetchRequest<R>) async throws -> [R] {
        try await perform {
            return try self.fetch(request)
        }
    }
}

This way we keep the new code committed to the async/await model, isolating any source of pre-concurrency as much as possible.

/// Defaults to **false**.
/// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**.
/// - Returns: An async sequence of list of entities.
public func streamEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about renaming it to be more indicative of watching store updates. Something like:
updatesStream(), or simply ``updates()`.

)
} else {
if ignoreCache {
try await Self.streamEntitiesFromAPI(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about turning this to simply be:
func updateEntitiesFromAPI() async throws, then after it is finished you simply call Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context)

try await updateEntitiesFromAPI()
try await Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context)

with async/await we get to isolate async work in a better way than before.

Copy link
Contributor

@suhaibabsi-inst suhaibabsi-inst Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We even can re-write the whole function to git rid of Self.streamEntitiesFromCache:

    public func updatesStream(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> {

        if offlineModeInteractor?.isOfflineModeEnabled() == true {

            return Self.streamEntitiesFromDatabase(
                fetchRequest: request,
                context: context
            )
        } else {

            let hasExpired = await useCase.hasCacheExpired(environment: environment)

            if ignoreCache || hasExpired {
                try await Self.updateEntitiesFromAPI(
                    useCase: useCase,
                    loadAllPages: loadAllPages,
                    fetchRequest: request,
                    context: context,
                    environment: environment
                )
            }

            return Self.streamEntitiesFromDatabase(
                fetchRequest: request,
                context: context
            )
        }
    }

Comment on lines +152 to +155
let scope = useCase.scope
let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self))
request.predicate = scope.predicate
request.sortDescriptors = scope.order
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be refactored out to a computed property or set at the initializer:

    private var request: NSFetchRequest<U.Model> {
        let scope = useCase.scope
        let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self))
        request.predicate = scope.predicate
        request.sortDescriptors = scope.order
        return request
    }

) async throws -> [T] {
let hasExpired = await useCase.hasCacheExpired(environment: environment)

return if hasExpired {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid using if for returning or setting a value. If necessary use it in a non-escaping closure:

let someValue = {
   if condition { val1 } else { val2 }
}()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants